agentmux_srv\drone\executor\blocks/
api.rs1use std::collections::HashMap;
19use std::net::{Ipv4Addr, Ipv6Addr};
20use std::sync::OnceLock;
21use std::time::Duration;
22
23use serde_json::{json, Value};
24
25use crate::drone::data_flow::ExecutionScope;
26use crate::drone::types::FlowNode;
27
28const DEFAULT_TIMEOUT_MS: u64 = 30_000;
29
30static HTTP_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
35
36fn http_client() -> &'static reqwest::Client {
37 HTTP_CLIENT.get_or_init(|| {
38 reqwest::Client::builder()
39 .redirect(reqwest::redirect::Policy::custom(|attempt| {
44 if let Err(e) = validate_url_for_safety(attempt.url()) {
45 return attempt.error(format!("redirect blocked: {e}"));
46 }
47 if attempt.previous().len() >= 10 {
48 return attempt.error("too many redirects");
49 }
50 attempt.follow()
51 }))
52 .build()
53 .expect("reqwest client build failed")
54 })
55}
56
57fn validate_url_safety(url_str: &str) -> Result<(), String> {
65 let url = reqwest::Url::parse(url_str)
66 .map_err(|e| format!("invalid URL: {e}"))?;
67 validate_url_for_safety(&url)
68}
69
70fn validate_url_for_safety(url: &reqwest::Url) -> Result<(), String> {
74 match url.scheme() {
75 "http" | "https" => {}
76 other => return Err(format!("URL scheme `{other}` is not allowed (http/https only)")),
77 }
78 let host = url
79 .host_str()
80 .ok_or_else(|| "URL missing host".to_string())?;
81 if let Some(inner) = host.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
85 let v6: Ipv6Addr = inner
86 .parse()
87 .map_err(|e| format!("invalid IPv6 host `{inner}`: {e}"))?;
88 if is_reserved_v6(&v6) {
89 return Err(format!("host `{v6}` is a reserved IPv6 address"));
90 }
91 return Ok(());
92 }
93 if host.eq_ignore_ascii_case("localhost") {
94 return Err("host `localhost` is not allowed".to_string());
95 }
96 if let Ok(v4) = host.parse::<Ipv4Addr>() {
97 if is_reserved_v4(&v4) {
98 return Err(format!("host `{v4}` is a reserved/private IP"));
99 }
100 }
101 Ok(())
104}
105
106fn is_reserved_v4(v4: &Ipv4Addr) -> bool {
107 v4.is_loopback()
108 || v4.is_private()
109 || v4.is_link_local()
110 || v4.is_broadcast()
111 || v4.is_unspecified()
112 || v4.is_multicast()
113}
114
115fn is_reserved_v6(v6: &Ipv6Addr) -> bool {
116 if v6.is_loopback()
121 || v6.is_unspecified()
122 || v6.is_multicast()
123 || (v6.segments()[0] & 0xfe00) == 0xfc00
126 || (v6.segments()[0] & 0xffc0) == 0xfe80
128 {
129 return true;
130 }
131 if let Some(v4) = v6.to_ipv4_mapped() {
137 return is_reserved_v4(&v4);
138 }
139 #[allow(deprecated)]
140 if let Some(v4) = v6.to_ipv4() {
141 return is_reserved_v4(&v4);
145 }
146 false
147}
148
149pub async fn run(node: &FlowNode, scope: &ExecutionScope) -> Result<Value, String> {
150 let method_raw = node
151 .data
152 .get("method")
153 .and_then(|v| v.as_str())
154 .unwrap_or("GET")
155 .to_uppercase();
156 let url_raw = node
157 .data
158 .get("url")
159 .and_then(|v| v.as_str())
160 .ok_or_else(|| "API block missing `url`".to_string())?;
161 let url = scope.resolve(url_raw);
162 if url.trim().is_empty() {
163 return Err("API block resolved URL is empty".to_string());
164 }
165 validate_url_safety(&url)?;
166
167 let headers_map: HashMap<String, String> = match node.data.get("headers") {
168 Some(Value::Object(obj)) => obj
169 .iter()
170 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), scope.resolve(s))))
171 .collect(),
172 _ => HashMap::new(),
173 };
174
175 let body_resolved: Option<String> = node
176 .data
177 .get("body")
178 .and_then(|v| v.as_str())
179 .map(|s| scope.resolve(s));
180
181 let timeout_ms = node
182 .data
183 .get("timeout_ms")
184 .and_then(|v| v.as_u64())
185 .unwrap_or(DEFAULT_TIMEOUT_MS);
186
187 let client = http_client();
188 let method = reqwest::Method::from_bytes(method_raw.as_bytes())
189 .map_err(|e| format!("invalid method `{method_raw}`: {e}"))?;
190 let mut req = client
191 .request(method, &url)
192 .timeout(Duration::from_millis(timeout_ms));
193 for (k, v) in &headers_map {
194 req = req.header(k, v);
195 }
196 if let Some(b) = body_resolved {
197 if !b.is_empty() {
198 req = req.body(b);
199 }
200 }
201 let resp = req.send().await.map_err(|e| format!("request failed: {e}"))?;
202 let status = resp.status().as_u16();
203 let resp_headers: HashMap<String, String> = resp
204 .headers()
205 .iter()
206 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
207 .collect();
208 let bytes = resp.bytes().await.map_err(|e| format!("read body: {e}"))?;
209 let text = String::from_utf8_lossy(&bytes).to_string();
210 let body_val: Value = serde_json::from_str(&text).unwrap_or(Value::String(text));
212
213 Ok(json!({
214 "status": status,
215 "body": body_val,
216 "headers": resp_headers,
217 }))
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn ssrf_rejects_loopback() {
226 assert!(validate_url_safety("http://127.0.0.1/").is_err());
227 assert!(validate_url_safety("http://127.0.0.1:8080/x").is_err());
228 assert!(validate_url_safety("https://[::1]/").is_err());
229 }
230
231 #[test]
232 fn ssrf_rejects_localhost_hostname() {
233 assert!(validate_url_safety("http://localhost/").is_err());
234 assert!(validate_url_safety("http://LocalHost:80/").is_err());
235 }
236
237 #[test]
238 fn ssrf_rejects_aws_metadata_endpoint() {
239 assert!(validate_url_safety("http://169.254.169.254/latest/meta-data/").is_err());
243 }
244
245 #[test]
246 fn ssrf_rejects_rfc1918_private() {
247 assert!(validate_url_safety("http://10.0.0.1/").is_err());
248 assert!(validate_url_safety("http://172.16.0.1/").is_err());
249 assert!(validate_url_safety("http://192.168.1.1/").is_err());
250 }
251
252 #[test]
253 fn ssrf_rejects_non_http_schemes() {
254 assert!(validate_url_safety("file:///etc/passwd").is_err());
255 assert!(validate_url_safety("ftp://example.com/").is_err());
256 assert!(validate_url_safety("gopher://example.com/").is_err());
257 }
258
259 #[test]
260 fn ssrf_rejects_ipv6_unique_local() {
261 assert!(validate_url_safety("http://[fc00::1]/").is_err());
263 assert!(validate_url_safety("http://[fd00::1]/").is_err());
264 }
265
266 #[test]
267 fn ssrf_rejects_ipv4_mapped_ipv6_to_reserved() {
268 assert!(validate_url_safety("http://[::ffff:127.0.0.1]/").is_err());
272 assert!(validate_url_safety("http://[::ffff:169.254.169.254]/").is_err());
273 assert!(validate_url_safety("http://[::ffff:10.0.0.1]/").is_err());
274 assert!(validate_url_safety("http://[::ffff:192.168.1.1]/").is_err());
275 }
276
277 #[test]
278 fn ssrf_rejects_ipv4_compatible_ipv6_to_reserved() {
279 assert!(validate_url_safety("http://[::127.0.0.1]/").is_err());
282 }
283
284 #[test]
285 fn ssrf_allows_public_ipv4_in_mapped_form() {
286 assert!(validate_url_safety("https://[::ffff:8.8.8.8]/").is_ok());
289 }
290
291 #[test]
292 fn ssrf_allows_public_hostnames() {
293 assert!(validate_url_safety("https://api.example.com/v1/users").is_ok());
294 assert!(validate_url_safety("http://example.com:8080/").is_ok());
295 }
296
297 #[test]
298 fn ssrf_allows_public_ip_literal() {
299 assert!(validate_url_safety("https://8.8.8.8/").is_ok());
301 }
302}